Updater = { }

Updater.maxRetries = 5

--[[

]]--

local updaterWindow
local loadModulesFunction
local scheduledEvent
local httpOperationId = 0
local UPDATER_DEBUG = false

local function onLog(level, message, time)
  if level == LogError then    
    Updater.error(message)
    g_logger.setOnLog(nil)
  end
end

local function initAppWindow()
  if g_resources.getLayout() == "mobile" then
    g_window.setMinimumSize({ width = 640, height = 360 })
  else
    g_window.setMinimumSize({ width = 800, height = 640 })  
  end

  -- window size
  local size = { width = 1024, height = 600 }
  size = g_settings.getSize('window-size', size)
  g_window.resize(size)

  -- window position, default is the screen center
  local displaySize = g_window.getDisplaySize()
  local defaultPos = { x = (displaySize.width - size.width)/2,
                       y = (displaySize.height - size.height)/2 }
  local pos = g_settings.getPoint('window-pos', defaultPos)
  pos.x = math.max(pos.x, 0)
  pos.y = math.max(pos.y, 0)
  g_window.move(pos)

  -- window maximized?
  local maximized = g_settings.getBoolean('window-maximized', false)
  if maximized then g_window.maximize() end

  g_window.setTitle(g_app.getName())
  g_window.setIcon('/images/clienticon')
  
  if g_app.isMobile() then
    scheduleEvent(function()
      g_app.scale(5.0)
    end, 10)
  end
end

local function loadModules()
  if loadModulesFunction then
    local tmpLoadFunc = loadModulesFunction
    loadModulesFunction = nil
    tmpLoadFunc()
  end
end

local function downloadFiles(url, files, index, retries, doneCallback)
  if not updaterWindow then return end
  local entry = files[index]
  if not entry then -- finished
    return doneCallback()
  end
  local file = entry[1]
  local file_checksum = entry[2]
  if not Updater._downloadedOk then
    Updater._downloadedOk = {}
  end
  
  if retries > 0 then
    updaterWindow.downloadStatus:setText(tr("Downloading (%i retry):\n%s", retries, file))
  else
    updaterWindow.downloadStatus:setText(tr("Downloading:\n%s", file))
  end
  updaterWindow.downloadProgress:setPercent(0)
  updaterWindow.mainProgress:setPercent(math.floor(100 * index / #files))
  
  local downloadTarget = file
  local function buildDownloadUrl(baseUrl, relPath)
    local sep = "?"
    if string.find(relPath, "?", 1, true) then
      sep = "&"
    end
    local nonce = tostring(APP_VERSION or "") .. "_" .. tostring(g_clock and g_clock.millis and g_clock.millis() or os.time())
    return baseUrl .. relPath .. sep .. "cv=" .. nonce
  end
  httpOperationId = HTTP.download(buildDownloadUrl(url, file), downloadTarget,
    function (downloadedPath, checksum, err)
      if UPDATER_DEBUG then
        g_logger.info("[UPDATER DEBUG] download file=" .. tostring(file) .. " target=" .. tostring(downloadTarget) .. " downloadedPath=" .. tostring(downloadedPath))
      end
      if not err and checksum ~= file_checksum then
        if UPDATER_DEBUG then
          g_logger.error("[UPDATER DEBUG] Checksum Mismatch! File: " .. file .. " | Server Sent: " .. file_checksum .. " | Calculated: " .. checksum)
        end
        err = "Invalid checksum of: " .. file .. ".\nShould be " .. file_checksum .. ", is: " .. checksum
      end
      if err then
        if retries >= Updater.maxRetries then
          Updater.error("Can't download file: " .. file .. ".\nError: " .. err)
        else
          scheduledEvent = scheduleEvent(function()
            downloadFiles(url, files, index, retries + 1, doneCallback)
          end, 250)
        end
        return
      end
      -- Some engine builds leave files in /downloads only. Always try to promote to final path.
      local function ensureWriteDirPath(path)
        local dir = path:match("^(.*)/[^/]+$")
        if dir and dir:len() > 0 then
          pcall(function() g_resources.makeDir("/" .. dir) end)
        end
      end
      local function tryRead(path)
        local ok, contents = pcall(function() return g_resources.readFileContents(path) end)
        if ok and contents and contents:len() > 0 then return contents end
        return nil
      end
      local function fileExists(path)
        local ok, exists = pcall(function() return g_resources.fileExists(path) end)
        return ok and exists
      end
      local function fileChecksum(path)
        local ok, sum = pcall(function() return g_resources.fileChecksum(path) end)
        if ok and sum and sum:len() > 0 then
          return tostring(sum):lower()
        end
        return ""
      end
      local expectedChecksum = tostring(file_checksum or ""):lower()
      local shouldPromote = true
      local localPath = "/" .. file
      if fileExists(localPath) then
        local localSum = fileChecksum(localPath)
        if localSum ~= "" and expectedChecksum == localSum then
          shouldPromote = false
        end
      end

      if shouldPromote then
        ensureWriteDirPath(file)
        -- Force overwrite semantics for changed files.
        pcall(function()
          if g_resources and g_resources.deleteFile then
            g_resources.deleteFile(localPath)
          end
        end)
        local contents =
          tryRead("/downloads/" .. tostring(downloadTarget)) or
          tryRead("/downloads/" .. tostring(downloadedPath)) or
          tryRead("/downloads/" .. tostring(file)) or
          tryRead(tostring(downloadTarget)) or
          tryRead(tostring(downloadedPath))
        if contents then
          pcall(function() g_resources.writeFileContents(localPath, contents) end)
        else
          err = "Downloaded file could not be read for promotion: " .. tostring(file)
        end
      end
      if err then
        if retries >= Updater.maxRetries then
          Updater.error("Can't apply file: " .. file .. ".\nError: " .. err)
        else
          scheduledEvent = scheduleEvent(function()
            downloadFiles(url, files, index, retries + 1, doneCallback)
          end, 250)
        end
        return
      end
      -- Mark file as downloaded successfully (engine already validated checksum).
      Updater._downloadedOk[file] = downloadTarget
      downloadFiles(url, files, index + 1, 0, doneCallback)
    end,
    function (progress, speed)
      updaterWindow.downloadProgress:setPercent(progress)
      updaterWindow.downloadProgress:setText(speed .. " kbps")
    end)
end

local function updateFiles(data, keepCurrentFiles)
  if not updaterWindow then return end
  if type(data) ~= "table" then
    return Updater.error("Invalid data from updater api (not table)")
  end
  if type(data["error"]) == 'string' and data["error"]:len() > 0 then
    return Updater.error(data["error"])    
  end
  if not data["files"] or type(data["url"]) ~= 'string' or data["url"]:len() < 4 then
    return Updater.error("Invalid data from updater api: " .. json.encode(data, 2))
  end
  -- Ensure base URL ends with a slash so we can safely append relative paths.
  if data["url"]:sub(-1) ~= "/" then
    data["url"] = data["url"] .. "/"
  end
  if data["keepFiles"] then
    keepCurrentFiles = true
  end

  -- If running from folders (not data.zip), skip updateData to avoid fatal error.
  local fromArchive = g_resources.isLoadedFromArchive and g_resources.isLoadedFromArchive() or false
  pcall(function()
    if g_resources and g_resources.getWriteDir then
      if UPDATER_DEBUG then
        g_logger.info("[UPDATER DEBUG] writeDir=" .. tostring(g_resources.getWriteDir()) .. " fromArchive=" .. tostring(fromArchive))
      end
    end
  end)
  if not fromArchive then
    keepCurrentFiles = true
  end
  
  local newFiles = false
  local finalFiles = {}
  local localFiles = g_resources.filesChecksums()
  if UPDATER_DEBUG then
    g_logger.info("[UPDATER DEBUG] Local Files Checksums Start")
  end
  for file, checksum in pairs(localFiles) do
      if UPDATER_DEBUG then
        g_logger.info("[UPDATER DEBUG] File: " .. file .. " | Checksum: " .. checksum)
      end
  end
  if UPDATER_DEBUG then
    g_logger.info("[UPDATER DEBUG] Local Files Checksums End")
  end
  local toUpdate = {}
  local staleToDelete = {}
  local function verifyDownloadedFiles(list)
    local missing = {}
    for _, entry in ipairs(list) do
      local file = entry[1]
      local expected = entry[2]
      if Updater._downloadedOk and Updater._downloadedOk[file] then
        -- Trust engine checksum for files downloaded this session.
        goto continue
      end
      local isText = file:match("%.lua$") or file:match("%.otui$") or file:match("%.otmod$") or
                     file:match("%.otml$") or file:match("%.txt$") or file:match("%.cfg$") or
                     file:match("%.json$") or file:match("%.xml$")
      local isSmallBinary = file:match("%.png$") or file:match("%.jpg$") or file:match("%.jpeg$") or file:match("%.bmp$")
      if not (isText or isSmallBinary) then
        -- Skip verification for large binaries (spr/dat/ogg/etc) to avoid false negatives.
        goto continue
      end
      local ok, sum = pcall(function() return g_resources.fileChecksum("/" .. file) end)
      if not ok or not sum or sum:len() == 0 then
        ok, sum = pcall(function() return g_resources.fileChecksum(file) end)
      end
      if (not ok or not sum or sum:len() == 0) and g_resources.fileExists then
        local ok2, exists = pcall(function() return g_resources.fileExists("/" .. file) end)
        if ok2 and exists then
          ok, sum = pcall(function() return g_resources.fileChecksum("/" .. file) end)
        end
      end
      if (not ok or not sum or sum:len() == 0) then
        local ok2, sum2 = pcall(function() return g_resources.fileChecksum("/downloads/" .. file) end)
        if ok2 and sum2 and sum2:len() > 0 then
          ok, sum = true, sum2
        end
      end
      if not ok or not sum or sum:len() == 0 then
        table.insert(missing, file)
      elseif expected and sum:lower() ~= tostring(expected):lower() then
        table.insert(missing, file)
      end
      ::continue::
    end
    return missing
  end

  local function normalizeKey(k)
    if type(k) ~= "string" then return "" end
    if k:sub(1, 1) == "/" then
      return k:sub(2)
    end
    return k
  end

  local function resourceExists(file)
    if not g_resources or not g_resources.fileExists then
      return false
    end
    local ok, exists = pcall(function() return g_resources.fileExists("/" .. file) end)
    if ok and exists then
      return true
    end
    ok, exists = pcall(function() return g_resources.fileExists(file) end)
    return ok and exists
  end

  local function isManagedPath(file)
    if type(file) ~= "string" then return false end
    if file == "init.lua" then return true end
    return string.find(file, "^data/") or
           string.find(file, "^modules/") or
           string.find(file, "^layouts/") or
           string.find(file, "^mods/")
  end

  local function deleteStaleFiles(list)
    local failed = {}
    if type(list) ~= "table" then
      return failed
    end
    for _, file in ipairs(list) do
      local deleted = false
      if g_resources and g_resources.deleteFile then
        local ok, res = pcall(function() return g_resources.deleteFile("/" .. file) end)
        if ok and (res == nil or res == true) then
          deleted = true
        end
        if not deleted then
          ok, res = pcall(function() return g_resources.deleteFile(file) end)
          if ok and (res == nil or res == true) then
            deleted = true
          end
        end
      end
      if not deleted then
        table.insert(failed, file)
      end
    end
    return failed
  end

  local function buildManifestHash(filesTable, binaryTable)
    if type(filesTable) ~= "table" then return "" end
    local keys = {}
    for k,_ in pairs(filesTable) do table.insert(keys, k) end
    table.sort(keys)
    local parts = {}
    for _, k in ipairs(keys) do
      table.insert(parts, k .. "=" .. tostring(filesTable[k]))
    end
    if type(binaryTable) == "table" and binaryTable["file"] then
      table.insert(parts, "binary:" .. tostring(binaryTable["file"]) .. "=" .. tostring(binaryTable["checksum"] or ""))
    end
    return table.concat(parts, "\n")
  end

  local function readAppliedManifestHash()
    local hash = ""
    pcall(function()
      hash = g_settings.getString("updater_last_manifest_hash", "")
    end)
    if (not hash or hash == "") and g_resources and g_resources.readFileContents then
      pcall(function()
        hash = g_resources.readFileContents("/updater_last_manifest_hash.txt") or ""
      end)
    end
    hash = tostring(hash or "")
    hash = hash:gsub("%s+$", "")
    return hash
  end

  local function writeAppliedManifestHash(hash)
    hash = tostring(hash or "")
    if hash == "" then return end
    pcall(function()
      g_settings.set("updater_last_manifest_hash", hash)
      if g_settings.save then
        g_settings.save()
      end
    end)
    pcall(function()
      if g_resources and g_resources.writeFileContents then
        g_resources.writeFileContents("/updater_last_manifest_hash.txt", hash)
      end
    end)
  end

  -- When not running from archive, rebuild local checksums using resource paths
  -- so we can compare against files downloaded into writeDir.
  local normalizedFiles = {}
  if type(data["files"]) == "table" then
    for file, checksum in pairs(data["files"]) do
      local norm = normalizeKey(file)
      if norm ~= "" then
        normalizedFiles[norm] = checksum
      end
    end
  end

  if not fromArchive and next(normalizedFiles) ~= nil then
    local rebuilt = {}
    for file, _ in pairs(normalizedFiles) do
      local ok, sum = pcall(function() return g_resources.fileChecksum("/" .. file) end)
      if (not ok or not sum or sum:len() == 0) then
        ok, sum = pcall(function() return g_resources.fileChecksum(file) end)
      end
      if ok and sum and sum:len() > 0 then
        rebuilt[file] = sum:lower()
      end
    end
    if next(rebuilt) ~= nil then
      localFiles = rebuilt
    end
  end
  -- keep all files or files from data/things
  for file, checksum in pairs(localFiles) do
    if keepCurrentFiles or string.find(file, "data/things") then
      table.insert(finalFiles, file)
    end
  end
  -- update files
  local manifestHash = buildManifestHash(normalizedFiles, data["binary"])

  for file, checksum in pairs(data["files"]) do
    local norm = normalizeKey(file)
    if norm ~= "" then
      table.insert(finalFiles, norm)
      if not localFiles[norm] or localFiles[norm] ~= checksum then
        table.insert(toUpdate, {norm, checksum})
      end
      newFiles = true
    end
  end

  local strictSync = data["strictSync"] == true
  if strictSync and not fromArchive then
    for file, _ in pairs(localFiles) do
      local norm = normalizeKey(file)
      if norm ~= "" and isManagedPath(norm) and not normalizedFiles[norm] then
        table.insert(staleToDelete, norm)
      end
    end
  end
  if not fromArchive then
    local lastHash = readAppliedManifestHash()
    if manifestHash ~= "" and lastHash == manifestHash then
      local missingOnly = {}
      for file, checksum in pairs(normalizedFiles) do
        if not resourceExists(file) then
          table.insert(missingOnly, {file, checksum})
        end
      end
      toUpdate = missingOnly
    end
  end
  -- update binary
  local binary = nil
  if type(data["binary"]) == "table" and data["binary"]["file"]:len() > 1 then
    local selfChecksum = g_resources.selfChecksum()
    if selfChecksum:len() > 0 and selfChecksum ~= data["binary"]["checksum"] then
      binary = data["binary"]["file"]
      table.insert(toUpdate, {binary, data["binary"]["checksum"]})
    end
  end
  if not fromArchive then
    local lastHash = readAppliedManifestHash()
    -- Circuit breaker only when there is truly nothing to do.
    if manifestHash ~= "" and lastHash == manifestHash and #toUpdate == 0 and #staleToDelete == 0 then
      updaterWindow.mainProgress:setPercent(100)
      scheduledEvent = scheduleEvent(Updater.abort, 20)
      return
    end
  end
  
  if #toUpdate == 0 and #staleToDelete == 0 then -- nothing to update
    updaterWindow.mainProgress:setPercent(100)
    if manifestHash ~= "" then
      writeAppliedManifestHash(manifestHash)
    end
    scheduledEvent = scheduleEvent(Updater.abort, 20)
    return
  end
  
  -- update of some files require full client restart
  local forceRestart = false
  local reloadModules = false
  local forceRestartPattern = {"init.lua", "corelib", "updater", "otmod"}
  for _, file in ipairs(toUpdate) do
    for __, pattern in ipairs(forceRestartPattern) do
      if string.find(file[1], pattern) then
        forceRestart = true
      end
      if not string.find(file[1], "data/things") then
        reloadModules = true
      end
    end
    if string.find(file[1], "^layouts/") or string.find(file[1], "^data/") then
      forceRestart = true
    end
  end
  
  updaterWindow.status:setText(tr("Updating %i files", #toUpdate + #staleToDelete))
  updaterWindow.mainProgress:setPercent(0)
  updaterWindow.downloadProgress:setPercent(0)
  updaterWindow.downloadProgress:show()
  updaterWindow.downloadStatus:show()
  updaterWindow.changeUrlButton:hide()
  -- Do not force "." as high-priority search path, or local client files may
  -- override repaired files from writeDir and cause update loops.
  -- Ensure parent directories exist in writeDir before downloading.
  -- This prevents silent failures when the updater tries to write nested paths.
  for _, entry in ipairs(toUpdate) do
    local path = entry[1]
    local dir = path:match("^(.*)/[^/]+$")
    if dir and dir:len() > 0 then
      pcall(function() g_resources.makeDir("/" .. dir) end)
    end
  end
  downloadFiles(data["url"], toUpdate, 1, 0, function()
    updaterWindow.status:setText(tr("Updating client (may take few seconds)"))
    updaterWindow.mainProgress:setPercent(100)
    updaterWindow.downloadProgress:hide()
    updaterWindow.downloadStatus:hide() 
    scheduledEvent = scheduleEvent(function()
      local missing = verifyDownloadedFiles(toUpdate)
      if #missing > 0 then
        return Updater.error("Updater downloaded files but could not verify:\n" .. table.concat(missing, "\n"))
      end
      local failedDeletes = deleteStaleFiles(staleToDelete)
      if #failedDeletes > 0 then
        return Updater.error("Updater could not remove old files:\n" .. table.concat(failedDeletes, "\n"))
      end
      local restart = binary or (not loadModulesFunction and reloadModules) or forceRestart
      if #staleToDelete > 0 then
        restart = true
      end
      if not fromArchive then
        pcall(function()
          if g_resources and g_resources.getWriteDir and g_resources.addSearchPath then
            local wd = g_resources.getWriteDir()
            if wd and wd ~= "" then
              g_resources.addSearchPath(wd, true)
            end
          end
        end)
        if manifestHash ~= "" then
          writeAppliedManifestHash(manifestHash)
        end
        if restart then
          g_app.restart()
          return
        end
        if reloadModules then
          g_modules.reloadModules()
        end
        Updater.abort()
        return
      end

      if newFiles then
        g_resources.updateData(finalFiles, not restart)
      end
      if binary then
        g_resources.updateExecutable(binary)
      end
      if restart then
        if manifestHash ~= "" then
          writeAppliedManifestHash(manifestHash)
        end
        g_app.restart()
      else
        if reloadModules then
          g_modules.reloadModules()
        end
        if manifestHash ~= "" then
          writeAppliedManifestHash(manifestHash)
        end
        Updater.abort()
      end
    end, 100)  
  end)
end

-- public functions
function Updater.init(loadModulesFunc)
  g_logger.setOnLog(onLog)
  loadModulesFunction = loadModulesFunc
  initAppWindow()
  Updater.check()
end

function Updater.terminate()
  loadModulesFunction = nil
  Updater.abort()
end

function Updater.abort()
  HTTP.cancel(httpOperationId)
  removeEvent(scheduledEvent)
  if updaterWindow then
    updaterWindow:destroy()
    updaterWindow = nil
  end
  loadModules()
end

function Updater.check(args)
  if updaterWindow then return end
  
  updaterWindow = g_ui.displayUI('updater')
  updaterWindow:show()
  updaterWindow:focus()
  updaterWindow:raise()  
  
  local updateData = nil
  local function progressUpdater(value)
    removeEvent(scheduledEvent)
    if value == 100 then
      return Updater.error(tr("Timeout"))
    end
    if updateData and (value > 60 or (not g_app.isMobile() or not ALLOW_CUSTOM_SERVERS or not loadModulesFunc)) then -- gives 3s to set custom updater for mobile version
      return updateFiles(updateData)
    end
    scheduledEvent = scheduleEvent(function() progressUpdater(value + 1) end, 50)
    updaterWindow.mainProgress:setPercent(value)
  end
  progressUpdater(0)

  httpOperationId = HTTP.postJSON(Services.updater, {
    version = APP_VERSION,
    build = g_app.getVersion(),
    os = g_app.getOs(),
    platform = g_window.getPlatformType(),
    args = args or {}
  }, function(data, err)
    if err then      
      if UPDATER_DEBUG then
        g_logger.error("[UPDATER DEBUG] HTTP Error: " .. tostring(err))
      end
      return Updater.error(err)
    end
    if UPDATER_DEBUG then
      g_logger.info("[UPDATER DEBUG] Received Data: " .. json.encode(data))
    end
    updateData = data
  end)
end

function Updater.error(message)
  removeEvent(scheduledEvent)
  if not updaterWindow then return end
  displayErrorBox(tr("Updater Error"), message).onOk = function()
    Updater.abort()
  end
end

function Updater.changeUrl()
  removeEvent(scheduledEvent)
  modules.client_textedit.edit(Services.updater, {title="Enter updater url", width=300}, function(newUrl)
    if updaterWindow and newUrl:len() > 4 then
      Services.updater = newUrl
    end
    if updaterWindow then
      updaterWindow:destroy()
      updaterWindow = nil
    end
    Updater.check()
  end)
end
